Skip to content

fix(link): align app-router prefetch scheduling with intent#1149

Merged
james-elicx merged 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/link-prefetch-parity
May 13, 2026
Merged

fix(link): align app-router prefetch scheduling with intent#1149
james-elicx merged 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/link-prefetch-parity

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 9, 2026

Review dashboard

Field Details
Goal Match Next.js App Router <Link> prefetch behavior for dev mode and user intent.
Core change Disable automatic Link RSC prefetch outside production and add high-priority mouse/touch intent prefetch in production.
Main behaviour / boundary / design concern Link owns prefetch scheduling from browser signals; the RSC/router cache owns payload storage and reuse.
Primary files to review packages/vinext/src/shims/link.tsx, then tests/link-navigation.test.ts, then the E2E expectation cleanup.
Expected impact Dev stops issuing automatic Link prefetch requests. Production keeps viewport prefetch and also warms RSC data on mouse/touch intent.

Why

App Router <Link> prefetching should follow the same basic expectation as Next.js: automatic prefetch is a production behavior, and strong navigation intent can warm the next RSC payload before click. This PR restores that expectation in the Link shim without changing router navigation or RSC cache semantics.

Area Principle / invariant What this PR changes
Link scheduling Link decides whether browser visibility or user intent should schedule a prefetch. Adds one production-only eligibility gate for viewport and intent prefetch.
Development mode Development should avoid automatic Link RSC prefetching. Visible Links no longer register with the shared prefetch observer or fetch RSC payloads in dev.
User intent Mouse/touch intent is a stronger signal than viewport visibility. onMouseEnter and onTouchStart now schedule high-priority RSC prefetch after user handlers run.
Prefetch targets Viewport and intent prefetch should agree on same-origin normalization and external URL rejection. Both paths use getPrefetchHref() before constructing the RSC prefetch URL.
Router/cache boundary Link schedules prefetch work; the navigation cache decides how prefetched payloads are stored and consumed. No changes to cache keys, payload format, cache consumption, or router navigation.

What changed

Scenario / surface Before After
Visible App Router Link in production Observed by IntersectionObserver and prefetched with low fetch priority. Same behavior.
Visible App Router Link in development Could observe and fetch an RSC prefetch payload. Does not observe or automatically fetch from Link prefetch scheduling.
Mouse enters a production Link User handler could run; no Vinext intent prefetch. User handler runs, then high-priority RSC prefetch is scheduled when eligible.
Touch starts on a production Link User handler could run; no Vinext intent prefetch. User handler runs, then high-priority RSC prefetch is scheduled when eligible.
prefetch={false} Disabled viewport prefetch. Disables both viewport and intent prefetch.
Dangerous href such as javascript: Rendered as an inert anchor; no prefetch. Still inert and not prefetched; user mouse/touch handlers are preserved.
App-router dev E2E cache expectations Some tests expected visible Links to populate the prefetch cache in dev. Those invalid expectations were removed or narrowed to a dev no-prefetch assertion.

Maintainer review path

File What to inspect
packages/vinext/src/shims/link.tsx Review shouldPrefetch, getPrefetchHref(), prefetchUrl(), prefetchOnIntent(), and rendered anchor handlers. The important behavior is production gating, shared same-origin normalization, low versus high fetch priority, prefetch={false}, and dangerous href handler preservation.
tests/link-navigation.test.ts Review the focused Link shim tests. The React runtime mock is intentionally narrow because production Link prefetch is not honestly covered by dev-server E2E.
tests/e2e/app-router/nextjs-compat/prefetch.spec.ts Review the removal of dev-server expectations that visible Links populate the RSC prefetch cache. The remaining changed assertion verifies the prefetch={false} target is not cached in dev.
tests/e2e/app-router/advanced.spec.ts Review deletion of an interception-cache prefetch test that depended on dev viewport prefetch. The removed helpers only served that test.

Tests

Added or changed coverage

Test area Behavior protected
tests/link-navigation.test.ts Production visible Links prefetch with low priority.
tests/link-navigation.test.ts Development visible Links do not observe or fetch.
tests/link-navigation.test.ts Mouse intent prefetches with high priority and preserves the user handler.
tests/link-navigation.test.ts Touch intent prefetches with high priority and preserves the user handler.
tests/link-navigation.test.ts prefetch={false} disables intent and viewport prefetch.
tests/link-navigation.test.ts Dangerous inert Links do not prefetch and still preserve user intent handlers.
tests/e2e/app-router/nextjs-compat/prefetch.spec.ts Dev E2E no longer asserts production-only visible prefetch cache population; it keeps a dev assertion that the prefetch={false} target is not cached.
tests/e2e/app-router/advanced.spec.ts Removed an invalid dev-server prefetch-cache assertion for intercepted route contexts.

Commands actually run

Command Result
vp test run tests/link-navigation.test.ts tests/link.test.ts tests/prefetch-cache.test.ts tests/nextjs-compat/prefetch.test.ts Passed: 4 files, 84 tests.
PLAYWRIGHT_PROJECT=app-router vp run test:e2e tests/e2e/app-router/nextjs-compat/prefetch.spec.ts tests/e2e/app-router/advanced.spec.ts Passed after clearing a stale Vite optimizer cache: 29 passed, 1 pre-existing skipped/fixme test.
vp check Passed formatting/type/lint checks with the existing unrelated warning at packages/vinext/src/server/request-pipeline.ts:604.
GitHub Actions for this PR head Passed: Check, Vitest, app-router E2E, other E2E projects, create-next-app checks, publish, and integration report.

Risk / compatibility

Surface Assessment
Public API impact No Link prop API changes. Existing prefetch={false}, user event handlers, and dangerous href behavior remain supported.
Build output impact No build pipeline or output format changes. This is client runtime shim behavior.
Runtime impact Changes when Link schedules RSC prefetch fetches. Development no longer performs automatic Link prefetch; production gains intent prefetch.
Cache/router/RSC impact No change to RSC payload format, cache keying, cache consumption, or router navigation semantics.
Workers impact No Workers-specific code changes. Workers apps receive the same client runtime scheduling behavior as other production targets.
Existing-app risk Apps relying on automatic Link prefetch side effects in development will no longer see those requests. Production viewport prefetch remains enabled for eligible Links.
Compatibility with framework behavior Reduces drift from Next.js for production-only prefetch and mouse/touch intent prefetch.
Intentional divergence None introduced.

Non-goals

Non-goal Reason
Implement Next.js partial prefetch for prefetch={null} / auto Vinext currently prefetches whole .rsc payloads. Matching Next.js partial prefetch requires router/server support for loading-boundary or segment-level prefetch responses, not just a Link shim change.
Change RSC prefetch cache semantics This PR only changes scheduling. Cache storage and consumption remain owned by the existing RSC navigation/prefetch cache code.
Create a general React component test harness The React runtime mocking added here is intentionally narrow to Link shim behavior and should not become a general component-testing pattern.
Change navigation click handling The existing app-router navigation scheduling test remains; this PR does not redesign click navigation.

References

Reference Why it matters
App Router Link prefetch prop docs/source Documents production-only prefetch behavior and App Router prefetch prop semantics.
Viewport prefetch development guard Shows Next.js returning early for Link visibility prefetch outside production.
Link mouse/touch intent handlers Shows Link invoking user handlers and scheduling navigation intent behavior from mouse/touch events.
Intent priority scheduling Shows Next.js distinguishing intent-triggered prefetch from viewport prefetch priority.
Next.js App Router Link implementation General implementation reference used for parity inspection.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 9, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1149

commit: f0b925e

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx let me know if you like this style of PR body better. Alternatively I could also do a proper HTML + CSS visualisation for more complex PR

@NathanDrake2406 NathanDrake2406 marked this pull request as draft May 9, 2026 15:29
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 11, 2026 10:47
Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path.

The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough.
Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path.

The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/link-prefetch-parity branch from f9f9dd2 to d7e142b Compare May 11, 2026 10:50
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good PR. Clean extraction of the prefetch decision logic into a testable module, correct Next.js parity for dev-mode suppression and intent prefetch, and honest cleanup of E2E tests that were asserting production-only behavior against the dev server.

A few things worth addressing:

Design notes

  1. Redundant decision calls: Both viewportPrefetchDecision and intentPrefetchDecision call getLinkPrefetchDecision with identical inputs except intent. Since the current decision function returns shouldPrefetch: false for the same conditions regardless of intent (the intent only affects priority), you're computing the same rejection logic twice per render. Today these are cheap, but you could compute the decision once and derive priority from intent when needed. Not blocking — just noting the redundancy.

  2. prefetch={true} vs prefetch={null} (default): Next.js App Router distinguishes these — prefetch={true} does a full prefetch including dynamic data, while the default (null) only prefetches the loading boundary (partial prefetch). The current getLinkPrefetchDecision treats true, null, and undefined identically. The PR body correctly notes partial prefetch as a non-goal, which is fine. Just confirming this is intentional and the discriminated union type won't mislead future contributors into thinking these cases are handled differently.

  3. Removed interception-context prefetch E2E test: The deleted test in advanced.spec.ts was the only E2E test verifying that prefetches from different interception contexts (feed vs gallery) produce separate cache entries. The unit-level coverage in tests/prefetch-cache.test.ts still covers this at the cache layer, but there's no longer an integration-level test exercising the full flow (viewport observe → prefetch → cache key separation). Worth filing a follow-up to restore this test once production-mode E2E is feasible, so the interception-context cache-key separation stays protected at the integration level.

Minor issues

See inline comments.

Comment thread packages/vinext/src/shims/link.tsx Outdated
const shouldViewportPrefetch = viewportPrefetchDecision.shouldPrefetch;
const viewportPrefetchPriority = viewportPrefetchDecision.shouldPrefetch
? viewportPrefetchDecision.priority
: "low";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These fallback values ("low" / "high") are dead code — they're only read when shouldPrefetch is false, and in that case neither prefetchUrl nor the observer callback is ever invoked. They exist solely to avoid a conditional access on the discriminated union, but they introduce a false signal that priority matters in the disabled case.

Consider making the intent explicit:

Suggested change
: "low";
const shouldViewportPrefetch = viewportPrefetchDecision.shouldPrefetch;
const viewportPrefetchPriority = viewportPrefetchDecision.shouldPrefetch
? viewportPrefetchDecision.priority
: undefined;
const shouldIntentPrefetch = intentPrefetchDecision.shouldPrefetch;
const intentPrefetchPriority = intentPrefetchDecision.shouldPrefetch
? intentPrefetchDecision.priority
: undefined;

Then the call sites would use viewportPrefetchPriority! (or assert) only inside the shouldViewportPrefetch guard, making it clear priority is never used when prefetch is disabled. Not blocking, but reduces the misleading defaults.

currentOrigin: string | undefined;
}): string | null {
const { href, basePath, currentOrigin } = input;
if (!isAbsoluteOrProtocolRelative(href)) return href;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: for a non-absolute, non-protocol-relative href that doesn't start with / (e.g. a bare relative path like "foo" or "./bar"), this returns the raw string. That's fine because prefetchUrl and the viewport observer both run it through toBrowserNavigationHref downstream which resolves against window.location.href, but the contract of this function isn't obvious from its signature alone — it can return relative paths that aren't valid prefetch targets on their own.

A doc comment noting that callers must resolve relative results before use would help.

Comment thread packages/vinext/src/shims/link.tsx Outdated
}
return <a {...anchorProps}>{children}</a>;
return (
<a {...anchorProps} onMouseEnter={onMouseEnter} onTouchStart={onTouchStart}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch preserving user handlers on dangerous links. One thing: the non-dangerous path uses handleMouseEnter / handleTouchStart (which wrap user handlers + prefetchOnIntent), but the dangerous path passes the raw onMouseEnter / onTouchStart directly. This is correct because prefetchOnIntent would bail out for dangerous hrefs anyway (via the decision function), but the asymmetry makes the two branches look different when they'd behave the same. Using handleMouseEnter / handleTouchStart here too would unify the branches and make it self-evident that prefetch is suppressed via the decision function, not via code path divergence.

Not blocking since behavior is correct either way.

// This is a tactical escape hatch for Link only. It intercepts React and JSX
// runtime output because the current E2E setup cannot honestly reach the
// production-only Link prefetch path. Do not reuse it as a component harness.
function mockReactAnchorCaptureForLinkOnly_DO_NOT_REUSE(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate the loud name. The _DO_NOT_REUSE suffix is good, but consider also adding a brief note explaining why this approach is fragile: it mocks useEffect synchronously (skipping React's commit-phase scheduling) and intercepts createElement/JSX at the call site (not at the reconciler level), so it can't test re-renders, cleanup, or conditional effect execution. This context would help future contributors understand why they shouldn't copy this pattern for other component tests.

if (key.includes("no-prefetch.rsc")) return true;
}
return false;
await page.evaluate(async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This manual fetch call is a clever way to verify the instrumentation is working before asserting on the absence of prefetch fetches. Worth a brief inline comment explaining the intent:

Suggested change
await page.evaluate(async () => {
// Verify the fetch instrumentation is capturing .rsc URLs correctly
// before relying on it to assert the absence of prefetch fetches.
await page.evaluate(async () => {
await window.fetch("/nextjs-compat/prefetch-test/target.rsc");
});

});
}

async function flushPrefetchTasks(): Promise<void> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three microtask ticks may not be sufficient if the prefetch path gains an additional async hop (e.g., createRscRequestUrl is async). Consider adding a brief comment explaining why three ticks is currently sufficient:

Suggested change
async function flushPrefetchTasks(): Promise<void> {
async function flushPrefetchTasks(): Promise<void> {
// prefetchUrl schedules via requestIdleCallback (mocked as sync) then
// enters an async IIFE with a single await (createRscRequestUrl). Three
// microtask ticks drain the current chain. Update if the async depth grows.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 11, 2026

Review posted. Here's a summary of my findings:

Overall: Solid PR. The extraction of prefetch decision logic into link-prefetch.ts is a good separation, the Next.js parity for dev-mode suppression and mouse/touch intent prefetch is correct, and the E2E test cleanup is honest — those tests were asserting production-only behavior against the dev server.

Design-level notes:

  1. The two getLinkPrefetchDecision calls per render are redundant since they share the same rejection logic (only priority differs). Could be computed once.
  2. prefetch={true} vs prefetch={null} are treated identically, which is fine since partial prefetch is a documented non-goal.
  3. The removed interception-context E2E test was the only integration-level test for that cache-key separation flow — worth a follow-up to restore it when production-mode E2E is feasible.

Inline comments:

  • Dead-code fallback priorities ("low" / "high") on the disabled branch — consider undefined to make intent clearer
  • getLinkPrefetchHref can return raw relative paths — needs a doc comment about caller responsibility
  • Dangerous-link branch uses raw handlers instead of handleMouseEnter/handleTouchStart — correct but asymmetric
  • Test helper naming is good (_DO_NOT_REUSE) but could explain why (mocked useEffect skips commit-phase scheduling)
  • flushPrefetchTasks should document why three microtask ticks is sufficient
  • E2E manual fetch call deserves a comment explaining it's verifying instrumentation

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good PR. The extraction of prefetch eligibility into link-prefetch.ts, the production-only gate, and the intent prefetch handlers are all correct and well-aligned with Next.js behavior.

What I verified

  • canLinkPrefetch correctly gates on production + prefetch !== false + !isDangerous. This matches the Next.js viewport prefetch guard in packages/next/src/client/components/links.ts.
  • getLinkPrefetchHref properly handles same-origin, cross-origin, protocol-relative, and basePath cases. The try/catch around URL parsing is the right defensive choice.
  • The dangerous-link branch already uses handleMouseEnter/handleTouchStart (not raw handlers), so user handlers are preserved and prefetch correctly no-ops via shouldPrefetch === false. This is unified with the normal path.
  • prefetchOnIntent correctly bails via shouldPrefetch before calling prefetchUrl, so dangerous hrefs, prefetch={false}, and dev mode all skip the fetch without needing separate code paths.
  • The viewport observer effect correctly depends on [shouldPrefetch, localizedHref] and calls getLinkPrefetchHref again for the observer callback, which is necessary because the effect captures the href at registration time.
  • The E2E test cleanup is honest — the removed tests were asserting production-only viewport prefetch behavior against the dev server, which was never valid.
  • getLinkPrefetchDecision is exported and tested but unused in production code. This is fine as a public API for the module, but see minor note below.

Notes for the previous review

The previous automated review contained several inaccurate observations that I want to correct:

  • The comment about "dead-code fallback priorities" and viewportPrefetchDecision/intentPrefetchDecision variables doesn't match this code. The PR uses canLinkPrefetch (boolean), not getLinkPrefetchDecision (discriminated union), in the component.
  • The comment about the dangerous path "passing raw onMouseEnter/onTouchStart directly" is wrong — the dangerous branch already uses handleMouseEnter/handleTouchStart.

Minor notes (non-blocking)

See inline comments.

shouldPrefetch: true,
priority: input.intent === "intent" ? "high" : "low",
};
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: getLinkPrefetchDecision is exported and tested but not used by any production code — only canLinkPrefetch and getLinkPrefetchHref are imported in link.tsx. If this is intentional as a public API for consumers or future use, that's fine, but a brief comment noting its purpose would help avoid someone removing it as dead code during cleanup.

}
return <a {...anchorProps}>{children}</a>;
return (
<a {...anchorProps} onMouseEnter={handleMouseEnter} onTouchStart={handleTouchStart}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: unlike the previous review's claim, the dangerous branch correctly uses handleMouseEnter/handleTouchStart (the wrapped handlers), not the raw props. This means both branches go through the same handler path, and prefetch is suppressed via shouldPrefetch === false in prefetchOnIntent. Clean.

// production-only Link prefetch path. It mocks useEffect synchronously and
// captures element creation before reconciliation, so it cannot test commit
// scheduling, cleanup, re-renders, or conditional effect execution. Do not
// reuse it as a component harness.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _DO_NOT_REUSE suffix and the preamble comment are good guardrails. One addition that would help: note that the mocked useEffect executes synchronously during render (no commit phase), so effect cleanup functions are never called. This matters if someone later adds a test that depends on cleanup behavior.

return false;
// Verify the fetch instrumentation sees .rsc URLs before relying on it
// to assert that Link prefetch does not issue a no-prefetch request.
await page.evaluate(async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good technique — verifying the instrumentation captures .rsc URLs before relying on it for absence assertions. This prevents false negatives where the test passes because the monkey-patched fetch wasn't set up correctly.

@@ -286,23 +270,6 @@ test.describe("Intercepting Routes", () => {
await expect(page.locator('[data-testid="feed-page"]')).toBeVisible();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed test was the only E2E-level test for interception-context cache-key separation (feed vs gallery producing separate prefetch cache entries). The unit test in tests/prefetch-cache.test.ts (line 174) still covers the cache-key logic directly, so the behavior is still protected. Worth a follow-up to restore this at the integration level once production-mode E2E testing is feasible.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 12, 2026

Review submitted with approval. Here's a summary:

Approved — the PR is correct and well-structured.

Key findings:

  • canLinkPrefetch correctly implements the production-only gate matching Next.js behavior
  • getLinkPrefetchHref handles all URL normalization cases (same-origin, cross-origin, protocol-relative, basePath)
  • Both dangerous and normal link branches use the same wrapped handlers, with prefetch suppression handled cleanly via shouldPrefetch === false
  • E2E test cleanup is honest — removed tests were asserting production-only behavior against the dev server
  • The previous automated review had several inaccurate observations (referencing variables and code paths that don't exist in this PR), which I corrected

Non-blocking notes were left inline about documenting getLinkPrefetchDecision's purpose (exported but unused in production code), the useEffect mock limitations, and a follow-up suggestion to restore the interception-context E2E test when production-mode testing is feasible.

github run

@james-elicx james-elicx merged commit 2d6978f into cloudflare:main May 13, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants